Un guide complet sur les génériques TypeScript, couvrant leur syntaxe, leurs avantages, leur utilisation avancée et les meilleures pratiques pour gérer des types de données complexes dans le développement logiciel mondial.
Génériques TypeScript : Maîtriser les types de données complexes pour des applications robustes
TypeScript, un sur-ensemble de JavaScript, permet aux développeurs d'écrire du code plus robuste et maintenable grâce au typage statique. Parmi ses fonctionnalités les plus puissantes se trouvent les génériques, qui vous permettent d'écrire du code pouvant fonctionner avec une variété de types de données tout en maintenant la sécurité des types. Ce guide propose une exploration complète des génériques TypeScript, en se concentrant sur leur application aux types de données complexes dans le contexte du développement logiciel mondial.
Que sont les génériques ?
Les génériques offrent un moyen d'écrire du code réutilisable qui peut fonctionner avec différents types. Au lieu d'écrire des fonctions ou des classes distinctes pour chaque type que vous souhaitez prendre en charge, vous pouvez écrire une seule fonction ou classe qui utilise des paramètres de type. Ces paramètres de type sont des espaces réservés pour les types réels qui seront utilisés lorsque la fonction ou la classe est appelée ou instanciée. Ceci est particulièrement utile lorsqu'on traite des structures de données complexes où le type de données au sein de ces structures peut varier.
Avantages de l'utilisation des génériques
- Réutilisabilité du code : Écrivez du code une seule fois et utilisez-le avec différents types. Cela réduit la duplication de code et rend votre base de code plus facile à maintenir.
- Sécurité des types : Les génériques permettent au compilateur TypeScript d'appliquer la sécurité des types au moment de la compilation. Cela aide à prévenir les erreurs d'exécution liées aux incohérences de type.
- Lisibilité améliorée : Les génériques rendent votre code plus lisible en indiquant clairement les types avec lesquels vos fonctions et classes sont conçues pour fonctionner.
- Performance améliorée : Dans certains cas, les génériques peuvent entraîner des améliorations de performance car le compilateur peut optimiser le code généré en fonction des types spécifiques utilisés.
Syntaxe de base des génériques
La syntaxe de base des génériques implique l'utilisation de chevrons (< >) pour déclarer des paramètres de type. Ces paramètres de type sont généralement nommés T
, K
, V
, etc., mais vous pouvez utiliser n'importe quel identifiant valide. Voici un exemple simple d'une fonction générique :
function identity<T>(arg: T): T {
return arg;
}
let myString: string = identity<string>("hello");
let myNumber: number = identity<number>(123);
let myBoolean: boolean = identity<boolean>(true);
console.log(myString); // Sortie : hello
console.log(myNumber); // Sortie : 123
console.log(myBoolean); // Sortie : true
Dans cet exemple, <T>
déclare un paramètre de type nommé T
. La fonction identity
prend un argument de type T
et renvoie une valeur de type T
. En appelant la fonction, vous pouvez spécifier explicitement le paramètre de type (par exemple, identity<string>
) ou laisser TypeScript l'inférer en fonction du type de l'argument.
Travailler avec des types de données complexes
Les génériques deviennent particulièrement précieux lorsqu'il s'agit de types de données complexes tels que les tableaux, les objets et les interfaces. Explorons quelques scénarios courants :
Tableaux génériques
Vous pouvez utiliser des génériques pour créer des fonctions ou des classes qui fonctionnent avec des tableaux de différents types :
function arrayToString<T>(arr: T[]): string {
return arr.join(", ");
}
let numberArray: number[] = [1, 2, 3, 4, 5];
let stringArray: string[] = ["apple", "banana", "cherry"];
console.log(arrayToString(numberArray)); // Sortie : 1, 2, 3, 4, 5
console.log(arrayToString(stringArray)); // Sortie : apple, banana, cherry
Ici, la fonction arrayToString
prend un tableau de type T[]
et renvoie une représentation en chaîne de caractères du tableau. Cette fonction fonctionne avec des tableaux de n'importe quel type, ce qui la rend hautement réutilisable.
Objets génériques
Les génériques peuvent également être utilisés pour définir des fonctions ou des classes qui fonctionnent avec des objets de différentes formes :
interface Person {
name: string;
age: number;
country: string; // Ajout du pays pour le contexte mondial
}
interface Product {
id: number;
name: string;
price: number;
currency: string; // Ajout de la devise pour le contexte mondial
}
function displayInfo<T extends { name: string }>(item: T): void {
console.log(`Nom : ${item.name}`);
}
let person: Person = { name: "Alice", age: 30, country: "USA" };
let product: Product = { id: 1, name: "Laptop", price: 1200, currency: "USD" };
displayInfo(person); // Sortie : Nom : Alice
displayInfo(product); // Sortie : Nom : Laptop
Dans cet exemple, la fonction displayInfo
prend un objet de type T
qui doit avoir une propriété name
de type chaîne de caractères. La clause extends { name: string }
est une contrainte, qui spécifie les exigences minimales pour le paramètre de type T
. Cela garantit que la fonction peut accéder en toute sécurité à la propriété name
.
Utilisation avancée des génériques
Les génériques TypeScript offrent des fonctionnalités plus avancées qui vous permettent de créer un code encore plus flexible et puissant. Explorons certaines de ces fonctionnalités :
Paramètres de type multiples
Vous pouvez définir des fonctions ou des classes avec plusieurs paramètres de type :
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
interface Name {
firstName: string;
}
interface Age {
age: number;
}
const person: Name = { firstName: "Bob" };
const details: Age = { age: 42 };
const merged = merge(person, details);
console.log(merged.firstName); // Sortie : Bob
console.log(merged.age); // Sortie : 42
La fonction merge
prend deux objets de types T
et U
et renvoie un nouvel objet qui contient les propriétés des deux objets. C'est un moyen puissant de combiner des données de différentes sources.
Contraintes génériques
Comme montré précédemment, les contraintes vous permettent de restreindre les types qui peuvent être utilisés avec un paramètre de type générique. Cela garantit que le code générique peut fonctionner en toute sécurité sur les types spécifiés.
interface Lengthwise {
length: number;
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length);
return arg;
}
loggingIdentity([1, 2, 3]); // Sortie : 3
loggingIdentity("hello"); // Sortie : 5
// loggingIdentity(123); // Erreur : L'argument de type 'number' n'est pas assignable au paramètre de type 'Lengthwise'.
La fonction loggingIdentity
prend un argument de type T
qui doit avoir une propriété length
de type nombre. Cela garantit que la fonction peut accéder en toute sécurité à la propriété length
.
Classes génériques
Les génériques peuvent également être utilisés avec les classes :
class DataStorage<T> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
this.data = this.data.filter(d => d !== item);
}
getItems(): T[] {
return [...this.data];
}
}
const textStorage = new DataStorage<string>();
textStorage.addItem("apple");
textStorage.addItem("banana");
textStorage.removeItem("apple");
console.log(textStorage.getItems()); // Sortie : [ 'banana' ]
const numberStorage = new DataStorage<number>();
numberStorage.addItem(1);
numberStorage.addItem(2);
numberStorage.removeItem(1);
console.log(numberStorage.getItems()); // Sortie : [ 2 ]
La classe DataStorage
peut stocker des données de n'importe quel type T
. Cela vous permet de créer des structures de données réutilisables qui sont typées en toute sécurité.
Interfaces génériques
Les interfaces génériques sont utiles pour définir des contrats qui peuvent fonctionner avec différents types. Par exemple :
interface Result<T, E> {
success: boolean;
data?: T;
error?: E;
}
interface User {
id: number;
username: string;
email: string;
}
interface ErrorMessage {
code: number;
message: string;
}
function fetchUser(id: number): Result<User, ErrorMessage> {
if (id === 1) {
return { success: true, data: { id: 1, username: "john.doe", email: "john.doe@example.com" } };
} else {
return { success: false, error: { code: 404, message: "Utilisateur non trouvé" } };
}
}
const userResult = fetchUser(1);
if (userResult.success) {
console.log(userResult.data.username);
} else {
console.log(userResult.error.message);
}
L'interface Result
définit une structure générique pour représenter le résultat d'une opération. Elle peut contenir soit des données de type T
, soit une erreur de type E
. C'est un modèle courant pour gérer les opérations asynchrones ou les opérations qui peuvent échouer.
Types utilitaires et génériques
TypeScript fournit plusieurs types utilitaires intégrés qui fonctionnent bien avec les génériques. Ces types utilitaires peuvent vous aider à transformer et à manipuler les types de manière puissante.
Partial<T>
Partial<T>
rend toutes les propriétés du type T
optionnelles :
interface Person {
name: string;
age: number;
}
type PartialPerson = Partial<Person>;
const partialPerson: PartialPerson = { name: "Alice" }; // Valide
Readonly<T>
Readonly<T>
rend toutes les propriétés du type T
en lecture seule :
interface Person {
name: string;
age: number;
}
type ReadonlyPerson = Readonly<Person>;
const readonlyPerson: ReadonlyPerson = { name: "Bob", age: 42 };
// readonlyPerson.age = 43; // Erreur : Impossible d'assigner à 'age' car c'est une propriété en lecture seule.
Pick<T, K>
Pick<T, K>
sélectionne un ensemble de propriétés K
du type T
:
interface Person {
name: string;
age: number;
email: string;
}
type NameAndAge = Pick<Person, "name" | "age">;
const nameAndAge: NameAndAge = { name: "Charlie", age: 28 };
Omit<T, K>
Omit<T, K>
supprime un ensemble de propriétés K
du type T
:
interface Person {
name: string;
age: number;
email: string;
}
type PersonWithoutEmail = Omit<Person, "email">;
const personWithoutEmail: PersonWithoutEmail = { name: "David", age: 35 };
Record<K, T>
Record<K, T>
crée un type avec des clés K
et des valeurs de type T
:
type CountryCodes = "US" | "CA" | "UK" | "DE" | "FR" | "JP" | "CN" | "IN" | "BR" | "AU"; // Liste étendue pour le contexte mondial
type Currency = "USD" | "CAD" | "GBP" | "EUR" | "JPY" | "CNY" | "INR" | "BRL" | "AUD"; // Liste étendue pour le contexte mondial
type CurrencyMap = Record<CountryCodes, Currency>;
const currencyMap: CurrencyMap = {
"US": "USD",
"CA": "CAD",
"UK": "GBP",
"DE": "EUR",
"FR": "EUR",
"JP": "JPY",
"CN": "CNY",
"IN": "INR",
"BR": "BRL",
"AU": "AUD",
};
Types mappés
Les types mappés vous permettent de transformer des types existants en itérant sur leurs propriétés. C'est un moyen puissant de créer de nouveaux types basés sur des types existants. Par exemple, vous pouvez créer un type qui rend toutes les propriétés d'un autre type en lecture seule :
interface Person {
name: string;
age: number;
}
type ReadonlyPerson = {
readonly [K in keyof Person]: Person[K];
};
const readonlyPerson: ReadonlyPerson = { name: "Eve", age: 25 };
// readonlyPerson.age = 26; // Erreur : Impossible d'assigner à 'age' car c'est une propriété en lecture seule.
Dans cet exemple, [K in keyof Person]
itère sur toutes les clés de l'interface Person
, et Person[K]
accède au type de chaque propriété. Le mot-clé readonly
rend chaque propriété en lecture seule.
Types conditionnels
Les types conditionnels vous permettent de définir des types basés sur des conditions. C'est un moyen puissant de créer des types qui s'adaptent à différents scénarios.
type NonNullable<T> = T extends null | undefined ? never : T;
type MaybeString = string | null | undefined;
type StringType = NonNullable<MaybeString>; // string
function getValue<T>(value: T): NonNullable<T> {
if (value == null) { // Gère à la fois null et undefined
throw new Error("La valeur ne peut pas être nulle ou indéfinie");
}
return value as NonNullable<T>;
}
try {
const validValue = getValue("hello");
console.log(validValue.toUpperCase()); // Sortie : HELLO
const invalidValue = getValue(null); // Ceci lèvera une erreur
console.log(invalidValue); // Cette ligne ne sera pas atteinte
} catch (error: any) {
console.error(error.message); // Sortie : La valeur ne peut pas être nulle ou indéfinie
}
Dans cet exemple, le type NonNullable<T>
vérifie si T
est null
ou undefined
. Si c'est le cas, il renvoie never
, ce qui signifie que le type n'est pas autorisé. Sinon, il renvoie T
. Cela vous permet de créer des types qui sont garantis de ne pas être nuls.
Meilleures pratiques pour l'utilisation des génériques
Voici quelques meilleures pratiques à garder à l'esprit lors de l'utilisation des génériques :
- Utilisez des noms de paramètres de type descriptifs : Choisissez des noms qui indiquent clairement le but du paramètre de type.
- Utilisez des contraintes pour limiter les types qui peuvent être utilisés avec un paramètre de type générique : Cela garantit que votre code générique peut fonctionner en toute sécurité sur les types spécifiés.
- Gardez votre code générique simple et ciblé : Évitez de compliquer excessivement votre code générique avec trop de paramètres de type ou des contraintes complexes.
- Documentez votre code générique de manière approfondie : Expliquez le but des paramètres de type et toutes les contraintes utilisées.
- Considérez les compromis entre la réutilisabilité du code et la sécurité des types : Bien que les génériques puissent améliorer la réutilisabilité du code, ils peuvent aussi rendre votre code plus complexe. Pesez les avantages et les inconvénients avant d'utiliser les génériques.
- Pensez à la localisation et à la mondialisation (l10n et g11n) : Lorsque vous traitez des données qui doivent être affichées aux utilisateurs dans différentes régions, assurez-vous que vos génériques prennent en charge le formatage et les conventions culturelles appropriés. Par exemple, le formatage des nombres et des dates peut varier considérablement d'une locale à l'autre.
Exemples dans un contexte mondial
Considérons quelques exemples de la manière dont les génériques peuvent être utilisés dans un contexte mondial :
Conversion de devises
interface ConversionRate {
rate: number;
fromCurrency: string;
toCurrency: string;
}
function convertCurrency<T extends ConversionRate>(amount: number, rate: T): number {
return amount * rate.rate;
}
const usdToEurRate: ConversionRate = { rate: 0.85, fromCurrency: "USD", toCurrency: "EUR" };
const amountInUSD = 100;
const amountInEUR = convertCurrency(amountInUSD, usdToEurRate);
console.log(`${amountInUSD} USD équivalent à ${amountInEUR} EUR`); // Sortie : 100 USD équivalent à 85 EUR
Formatage de date
interface DateFormatOptions {
locale: string;
options: Intl.DateTimeFormatOptions;
}
function formatDate<T extends DateFormatOptions>(date: Date, format: T): string {
return date.toLocaleDateString(format.locale, format.options);
}
const currentDate = new Date();
const usDateFormat: DateFormatOptions = { locale: "en-US", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const germanDateFormat: DateFormatOptions = { locale: "de-DE", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const japaneseDateFormat: DateFormatOptions = { locale: "ja-JP", options: { year: 'numeric', month: 'long', day: 'numeric' } };
console.log("Date US : " + formatDate(currentDate, usDateFormat));
console.log("Date allemande : " + formatDate(currentDate, germanDateFormat));
console.log("Date japonaise : " + formatDate(currentDate, japaneseDateFormat));
Service de traduction
interface Translation {
[key: string]: string; // Permet des clés de langue dynamiques
}
interface LanguageData<T extends Translation> {
languageCode: string;
translations: T;
}
const englishTranslations: Translation = {
"hello": "Hello",
"goodbye": "Goodbye",
"welcome": "Welcome to our website!"
};
const spanishTranslations: Translation = {
"hello": "Hola",
"goodbye": "Adiós",
"welcome": "¡Bienvenido a nuestro sitio web!"
};
const frenchTranslations: Translation = {
"hello": "Bonjour",
"goodbye": "Au revoir",
"welcome": "Bienvenue sur notre site web !"
};
const languageData: LanguageData<typeof englishTranslations>[] = [
{languageCode: "en", translations: englishTranslations },
{languageCode: "es", translations: spanishTranslations },
{languageCode: "fr", translations: frenchTranslations}
];
function translate<T extends Translation>(key: string, languageCode: string, languageData: LanguageData<T>[]): string {
const lang = languageData.find(lang => lang.languageCode === languageCode);
if (!lang) {
return `Traduction pour ${key} en ${languageCode} non trouvée.`;
}
return lang.translations[key] || `Traduction pour ${key} non trouvée.`;
}
console.log(translate("hello", "en", languageData)); // Sortie : Hello
console.log(translate("hello", "es", languageData)); // Sortie : Hola
console.log(translate("welcome", "fr", languageData)); // Sortie : Bienvenue sur notre site web !
console.log(translate("missingKey", "de", languageData)); // Sortie : Traduction pour missingKey en de non trouvée.
Conclusion
Les génériques TypeScript sont un outil puissant pour écrire du code réutilisable et typé en toute sécurité, capable de fonctionner avec des types de données complexes. En comprenant la syntaxe de base, les fonctionnalités avancées et les meilleures pratiques des génériques, vous pouvez améliorer considérablement la qualité et la maintenabilité de vos applications TypeScript. Lors du développement d'applications pour un public mondial, les génériques peuvent vous aider à gérer divers formats de données et conventions culturelles, garantissant une expérience utilisateur transparente pour tous.